#!/usr/bin/python
# Copyright (c) 2010, Chris Hall <chris.hall@mod10.net>
# This file is part of HTTPClient and is distributed under the MIT license.

"""
Usage: httpclient.py file

Parse an XML document into an HTTPRequest, send it, and write the resulting
HTTPResponse to the console.  The response body will be formatted nicely if the
`Content-Type` header indicates XML or XHTML.
"""

# HTTPConnection and HTTPSConnection have moved under python3.
try:
	import httplib
except ImportError:
	import http.client as httplib
import re
import string
import sys
from xml.dom import minidom
from xml.dom import Node
import xml.etree.ElementTree as ET
from xml.parsers.expat import ExpatError

_indent='  '

class HTTPClientError(Exception):
	pass

class HTTPRequest:
	def __init__(self, protocol, host, port, path, method, headers, body):
		self.protocol = protocol
		self.host = host
		self.port = port
		self.path = path
		self.method = method
		self.headers = headers
		self.body = body
		
class HTTPResponse:
	def __init__(self, version, status, reason, headers, body):
		self.version = version
		self.status = status
		self.reason = reason
		self.headers = headers
		self.body = body
		
	def __str__(self):
		lines = []
		lines.append('{0} {1} {2}'.format(self.version, self.status, self.reason))
		for name in self.headers:
			lines.append('{0}: {1}'.format(name, self.headers[name]))
		lines.append('')
		lines.append(self.body)
		return '\n'.join(lines)
		
class HTTPClient:
	_ports = {'http': httplib.HTTP_PORT, 'https': httplib.HTTPS_PORT}
	_versions = {10: 'HTTP/1.0', 11: 'HTTP/1.1'}
	
	def read_request(self, file):
		"""Read an XML document from the file system and convert it to an HTTPRequest."""
		
		document = ET.parse(file)
		url = document.findtext('url')
		m = re.match('^(?P<protocol>https?)://(?P<host>[^/:]*):?(?P<port>\d*)(?P<path>/.*)$', url, re.I)
		if not m:
			raise HTTPClientError('Invalid URL.')
		
		protocol = m.group('protocol').lower()
		host = m.group('host')
		port = m.group('port')
		path = m.group('path')
		method = document.findtext('method')
		body = document.findtext('body')

		if not port:
			port = self._ports[protocol]
		if not path:
			path = '/'
		if not method:
			method = 'GET'
		
		headers = {}
		for header in document.findall('headers/header'):
			headers[header.attrib['name']] = header.text
		
		return HTTPRequest(protocol, host, port, path, method, headers, body)
		
	def send_request(self, request):
		"""Send an HTTPRequest and return an HTTPResponse."""
		
		conn = None
		if request.protocol.lower() == 'https':
			conn = httplib.HTTPSConnection(request.host, request.port)
		else:
			conn = httplib.HTTPConnection(request.host, request.port)
		
		conn.request(request.method, request.path, request.body, request.headers)
		response = conn.getresponse()

		# The response body can only be read while the connection is open.  All
		# other fields can be read from the response object after the connection
		# has been closed.
		body = response.read()
		conn.close()
		
		# Convert the version code returned by httplib to a displayable value.
		version = self._versions[response.version]
		status = response.status
		reason = response.reason
		
		headers = {}
		for header in response.getheaders():
			headers[string.capwords(header[0], '-')] = header[1]

		# The response body is automatically decoded by httplib (python2), but
		# not by http.client (python3).
		if type(body).__name__ == 'bytes':
			body = body.decode('utf-8')

		# Format the response nicely if it's supposed to be XML or XHTML.
		if 'Content-Type' in headers and re.search('xml|xhtml', headers['Content-Type'], re.I):
			try:
				body = prettyprint_string(body)
				# The prettyprint is converting the body from a str to unicode in python2
				# when the response contains non-ASCII characters.  Encode it manually or
				# python2 will assume ASCII - which is probably not correct.
				if type(body).__name__ == 'unicode':
					body = body.encode('utf-8')
			except ExpatError:
				pass
		
		return HTTPResponse(version, status, reason, headers, body)
	
def prettyprint_element(element):
	"""Convert an XML element to a nicely indented string."""
	return element.toprettyxml(indent=_indent)

def prettyprint_string(text):
	"""Nicely indent a string containing XML."""
	doc = minidom.parseString(text)
	root = doc.childNodes[0]
	return prettyprint_element(root)

def main():
	"""Usage: httpclient.py file"""
	if len(sys.argv) != 2:
		print(main.__doc__)
		return 1
	
	client = HTTPClient()
	request = client.read_request(sys.argv[1])
	response = client.send_request(request)
	print(response)

def _patch_minidom():
	"""Modify minidom's printing to handle whitespace better."""
	
	def writexml_text(self, writer, indent="", addindent="", newl=""):
		"""Modify minidom.Text's printing rules.
		
		This has been copied from minidom.py, but with the following changes:
		1. Never add line breaks around a Text node.
		2. Trim whitespace before printing.
		
		"""
		minidom._write_data(writer, self.data.strip())
	minidom.Text.writexml = writexml_text
		
	def writexml_element(self, writer, indent="", addindent="", newl=""):
		"""Modify minidom.Element's printing rules.
		
		This has been copied from minidom.py, but with the following changes:
		1. Don't print a line break before the first child if it's an empty Text node.
		2. Don't print a line break after the last child if it's an empty Text node.
		
		"""
		# indent = current indentation
		# addindent = indentation to add to higher levels
		# newl = newline string
		writer.write(indent+"<" + self.tagName)
		
		attrs = self._get_attributes()
		a_names = sorted(attrs.keys())
		
		for a_name in a_names:
			writer.write(" %s=\"" % a_name)
			minidom._write_data(writer, attrs[a_name].value)
			writer.write("\"")
		if self.childNodes:
			if self.childNodes[0].nodeType == Node.TEXT_NODE and len(self.childNodes[0].data.strip()) > 0:
				writer.write(">")
			else:
				writer.write(">%s"%(newl))
			for node in self.childNodes:
				node.writexml(writer,indent+addindent,addindent,newl)
			if self.childNodes[-1].nodeType == Node.TEXT_NODE and len(self.childNodes[0].data.strip()) > 0:
				writer.write("</%s>%s" % (self.tagName,newl))
			else:
				writer.write("%s</%s>%s" % (indent,self.tagName,newl))
		else:
			writer.write("/>%s"%(newl))
	minidom.Element.writexml = writexml_element
	
_patch_minidom()
	
if __name__ == "__main__":
	main()
